package io.light.frame.dal.mybatis.generator.core;

import com.google.common.base.CaseFormat;
import com.google.common.base.Converter;
import io.light.frame.dal.mybatis.generator.core.cfg.GeneratorConfiguration;
import io.light.frame.dal.mybatis.generator.core.cfg.ModuleConfiguration;
import io.light.frame.dal.mybatis.generator.core.context.GenResult;
import io.light.frame.dal.mybatis.generator.core.context.GenerationContext;
import io.light.frame.dal.mybatis.generator.core.domain.DesignXml;
import io.light.frame.dal.mybatis.generator.core.domain.Generation;
import io.light.frame.dal.mybatis.generator.core.domain.clazz.Clazz;
import io.light.frame.dal.mybatis.generator.core.domain.clazz.ClazzField;
import io.light.frame.dal.mybatis.generator.core.domain.clazz.ClazzMode;
import io.light.frame.dal.mybatis.generator.core.domain.clazz.JavaKeyword;
import io.light.frame.dal.mybatis.generator.core.domain.mapper.Mapper;
import io.light.frame.dal.mybatis.generator.core.domain.mapper.MapperFunc;
import io.light.frame.dal.mybatis.generator.core.exceptions.InitializationException;
import io.light.frame.dal.mybatis.generator.core.exceptions.MybatisGenException;
import io.light.frame.dal.mybatis.generator.core.listener.GenerationListener;
import io.light.frame.dal.mybatis.generator.core.mapper.func.FuncBuilder;
import io.light.frame.dal.mybatis.generator.core.mapper.type.TypeHandler;
import io.light.frame.dal.mybatis.generator.core.meta.entity.Table;
import io.light.frame.dal.mybatis.generator.core.meta.visit.MetaAccessor;
import io.light.frame.dal.mybatis.generator.core.meta.visit.opt.MetaOperations;
import io.light.frame.dal.mybatis.generator.core.util.GenToolKit;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.dom4j.Document;
import org.dom4j.Element;
import org.dom4j.Node;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StopWatch;

import java.io.File;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;

/**
 * Mybatis generator
 *
 * @author Ivan
 * @version 1.0.0
 * @date 2021-05-23 11:27
 */
@Slf4j
public class MybatisGenerator implements ApplicationListener<ContextRefreshedEvent> {

    public static final Converter<String, String> TABLE_NAME_CAMEL_CONVERTER = CaseFormat.LOWER_UNDERSCORE
            .converterTo(CaseFormat.UPPER_CAMEL);

    private final GeneratorConfiguration configuration;

    private final TypeHandler typeHandler;

    private final MetaAccessor metaAccessor;

    private List<FuncBuilder> funcBuilders;

    private List<GenerationListener> listeners;

    /**
     * TODO reload able
     */
    private final Map<String, Map<String, DesignXml>> designXmlPool = new ConcurrentHashMap<>();

    public MybatisGenerator(GeneratorConfiguration configuration, TypeHandler typeHandler, MetaAccessor metaAccessor) {
        this.configuration = configuration;
        this.typeHandler = typeHandler;
        this.metaAccessor = metaAccessor;
    }

    public GenResult process(String tableName) {
        return process(null, tableName);
    }

    public GenResult process(String module, String tableName) {
        return process(module, null, tableName);
    }

    public GenResult process(String module, String database, String tableName) {
        StopWatch clock = null;
        if (log.isInfoEnabled()) {
            clock = new StopWatch();
            clock.start();
        }
        module = GenToolKit.determineModule(module, configuration);
        ModuleConfiguration config;
        if (GenToolKit.defaultModule(configuration).equals(module)) {
            config = configuration;
        } else {
            config = configuration.getOtherModules().get(module);
        }
        if (config == null) {
            throw new MybatisGenException(String.format("There is no configuration for the module '%s'", module));
        }
        if (database == null) {
            database = config.getDatabase();
        }
        if (StringUtils.isBlank(database)) {
            throw new MybatisGenException(String.format("The module '%s' does not specify a database", module));
        }
        // Prepare context
        Generation generation = new Generation();
        generation.setConfig(config);
        generation.setModuleName(module);
        generation.setProjectDir(GenToolKit.resolveProjectDir(module));
        generation.setDatabase(database);
        generation.setTableName(tableName);
        listeners.forEach(l -> l.onStart(generation));
        try {
            prepareContext(generation);
        } catch (Exception e) {
            GenerationContext.destroy();
            GenToolKit.handleException(log, String.format(
                    "Preparing context error, module: '%s', database:'%s', table:'%s'",
                    module, database, tableName), e);
        }
        // Process generation
        GenResult result = GenerationContext.result();
        try {
            process(GenerationContext.current());
            result.setSuccess(true);
            listeners.forEach(l -> l.onSuccess(result));
        } catch (Exception e) {
            result.setSuccess(false);
            GenerationContext.addErr(e);
            try {
                listeners.forEach(l -> l.onError(e));
            } catch (Throwable te) {
                log.error("An error occurred while listening generator exception =>", te);
            }
            GenToolKit.handleException(log, String.format("Generation process error, database:'%s', table:'%s'",
                    database, tableName), e);
        } finally {
            GenerationContext.destroy();
            try {
                listeners.forEach(l -> l.onComplete(result));
            } catch (Throwable te) {
                log.error("An error occurred while listening generation complete =>", te);
            }
            if (clock != null) {
                clock.stop();
                if (log.isInfoEnabled()) {
                    log.debug("Takes: " + clock.getTotalTimeMillis() + " ms");
                }
            }
        }
        return result;
    }

    private void prepareContext(Generation generation) throws Exception {
        ModuleConfiguration config = generation.getConfig();
        String moduleName = generation.getModuleName();
        String database = generation.getDatabase();
        String tableName = generation.getTableName();
        metaAccessor.touch(config.getDatasourceBeanId(), metaOperations -> {
            // Check database
            metaOperations.checkDatabase(database);
            // Get table metadata
            Table table = metaOperations.table(database, tableName);
            // Create table mapper
            Mapper mapper = new Mapper(table);
            typeHandler.handle(mapper, config);
            listeners.forEach(l -> l.onMetadataMapped(mapper));
            // Fetch design xml
            DesignXml designXml = designXmlPool.computeIfAbsent(moduleName, k ->
                    new ConcurrentHashMap<>(1 << 8)).get((database + "@" + tableName).toLowerCase());
            boolean exist = designXml != null;
            if (!exist) {
                designXml = GenToolKit.newSampleDesignXml(config, mapper);
            }
            GenerationContext.create(generation, mapper, designXml);
            if (!exist) {
                GenerationContext.addCreatedFile(designXml.getFile(), true);
            }
        });
    }

    private void process(GenerationContext context) throws Exception {
        GenResult.Detail resultDetail = GenerationContext.current().getResult().getDetail();
        ModuleConfiguration config = context.getConfig();
        File projectDir = context.getProjectDir();
        Mapper mapper = context.getMapper();
        DesignXml designXml = context.getDesignXml();
        metaAccessor.touch(config.getDatasourceBeanId(), metaOperations -> {
            Document designDoc = designXml.getDoc();
            if (log.isInfoEnabled()) {
                log.info("Processing generation design => {}", designXml.getFile().getAbsolutePath());
                log.info("Using datasource bean '{}'", config.getDatasourceBeanId());
            }
            Element mapperElement = designDoc.getRootElement();
            // Prepare entity class
            Clazz entityClazz = prepareEntityClazz(context);
            // Prepare mapper
            Clazz daoClazz = prepareMapper(context, mapperElement);
            // Gen entity + dao
            File entityOutDir = GenToolKit.resolvePackageDir(projectDir, config.getJavaBuildPath(),
                    entityClazz.getPkg());
            File daoOutDir = GenToolKit.resolvePackageDir(projectDir, config.getJavaBuildPath(),
                    daoClazz.getPkg());
            File entityFile = GenToolKit.createJavaFile(entityOutDir, config, entityClazz);
            File daoFile = GenToolKit.createJavaFile(daoOutDir, config, daoClazz);
            // Gen mapper xml
            boolean splitPkg = !config.getResourceBuildPath().contains("resource");
            File xmlOutDir = GenToolKit.resolvePackageDir(projectDir, config.getResourceBuildPath(),
                    config.getMapperXmlLocation(), splitPkg);
            File mapperXml = GenToolKit.createMapperXml(xmlOutDir, config, mapper);
            resultDetail.setDaoFile(daoFile);
            resultDetail.setEntityFile(entityFile);
            resultDetail.setMapperXml(mapperXml);
        });
    }

    protected Clazz prepareEntityClazz(GenerationContext context) {
        ModuleConfiguration config = context.getConfig();
        String pkg = config.getEntityPackage();
        Mapper mapper = context.getMapper();
        Table table = mapper.getTable();
        String className = TABLE_NAME_CAMEL_CONVERTER.convert(table.getName());
        String classComment = table.getComment();
        if (classComment != null) {
            classComment = classComment.replaceAll("[\\t\\n\\r]", ";");
            if (classComment.endsWith("\u8868")) {
                classComment = classComment.substring(0, classComment.indexOf("\u8868"));
            }
        }
        Clazz entityClazz = new Clazz(pkg, className, ClazzMode.CLASS, JavaKeyword.PUBLIC);
        entityClazz.setComment(classComment);
        entityClazz.addAnnotations(Getter.class, Setter.class);
        if (StringUtils.isNotBlank(config.getEntitySuperClass())) {
            entityClazz.setSuperClazz(Clazz.of(config.getEntitySuperClass()));
        }
        Optional.ofNullable(config.getEntityInterfaces()).ifPresent(ifcList -> {
            ifcList.stream().filter(StringUtils::isNotBlank).map(Clazz::of).forEach(entityClazz::addInterfaces);
        });
        mapper.getProperties().forEach(property -> {
            String comment = property.getColumn().getComment();
            if (comment != null) {
                comment = comment.replaceAll("[\\t\\n\\r]", ";");
            }
            ClazzField field = new ClazzField(property.getPropertyName(), property.getJavaType(), JavaKeyword.PRIVATE);
            field.setComment(comment);
            entityClazz.addFields(field);
        });
        mapper.setEntityClazz(entityClazz);
        listeners.forEach(listener -> listener.onEntityReady(entityClazz));
        return mapper.getEntityClazz();
    }

    protected Clazz prepareMapper(GenerationContext context, Element mapperElement) {
        String pkg = context.getConfig().getMapperPackage();
        String mapperNameSuffix = context.getConfig().getMapperNameSuffix();
        Mapper mapper = context.getMapper();
        // prepare the mapper`s functions
        prepareMapperFunctions(context, mapperElement);
        // prepare the mapper`s interface
        Clazz entityClazz = mapper.getEntityClazz();
        String daoName = entityClazz.getSimpleName() + mapperNameSuffix;
        String classComment = entityClazz.getComment();
        if (classComment != null) {
            classComment += " " + mapperNameSuffix;
            classComment = classComment.replaceAll("[\\t\\n\\r]", ";");
        }
        Clazz daoClazz = new Clazz(pkg, daoName, ClazzMode.INTERFACE, JavaKeyword.PUBLIC);
        daoClazz.setComment(classComment);
        daoClazz.addAnnotations(org.apache.ibatis.annotations.Mapper.class);
        mapper.getFuncList().stream().map(MapperFunc::asMethod).forEach(daoClazz::addMethods);
        mapper.setDaoClazz(daoClazz);
        listeners.forEach(listener -> listener.onMapperReady(mapper, daoClazz));
        return mapper.getDaoClazz();
    }

    protected void prepareMapperFunctions(GenerationContext context, Element mapperElement) {
        Mapper mapper = context.getMapper();
        Clazz entityClazz = mapper.getEntityClazz();
        Iterator<Node> iter = mapperElement.nodeIterator();
        List<String> comments = new ArrayList<>();
        while (iter.hasNext()) {
            Node curNode = iter.next();
            if (!(curNode instanceof Element)) {
                if (curNode.getNodeType() == Node.COMMENT_NODE && curNode.getText() != null) {
                    String comment = curNode.getText().trim();
                    if (StringUtils.isNotBlank(comment) && !comment.startsWith("!!")) {
                        String[] cmnArr = comment.split("\n");
                        for (String cmn : cmnArr) {
                            if (cmn == null) {
                                continue;
                            }
                            cmn = cmn.trim();
                            if (StringUtils.isBlank(cmn)) {
                                continue;
                            }
                            comments.add(cmn);
                        }
                    }
                }
                continue;
            }
            Element funcElement = (Element) curNode;
            if ("sql".equalsIgnoreCase(funcElement.getName())) {
                mapper.getSqlList().add(funcElement.asXML());
            }
            MapperFunc.Type funcType = MapperFunc.Type.of(funcElement);
            if (funcType != null) {
                MapperFunc mapperFunc = new MapperFunc(entityClazz, funcType, funcElement);
                if (!comments.isEmpty()) {
                    mapperFunc.setComment(String.join("\n     * ", comments));
                }
                mapperFunc.build((content, xmlElement) -> {
                    if (!content.isCompleted()) {
                        AtomicBoolean accepted = new AtomicBoolean(false);
                        funcBuilders.stream().filter(b -> b.accept(xmlElement, mapperFunc)).forEach(builder -> {
                            accepted.set(true);
                            builder.build(xmlElement, mapperFunc, content);
                        });
                        if (!accepted.get()) {
                            content.append(xmlElement.asXML());
                        }
                    } else {
                        listeners.forEach(listener -> listener.onMapperFuncReady(mapperFunc, content));
                    }
                });
                mapper.getFuncList().add(mapperFunc);
            }
            comments.clear();
        }
    }

    protected void init() throws Exception {
        Assert.notNull(configuration, "Generator configuration missing");
        funcBuilders = new ArrayList<>(GenToolKit.beanFactory().getBeansOfType(FuncBuilder.class).values());
        listeners = new ArrayList<>(GenToolKit.beanFactory().getBeansOfType(GenerationListener.class).values());
        if (!CollectionUtils.isEmpty(funcBuilders)) {
            AnnotationAwareOrderComparator.sort(funcBuilders);
        }
        if (!CollectionUtils.isEmpty(listeners)) {
            AnnotationAwareOrderComparator.sort(listeners);
        }
        funcBuilders = Collections.unmodifiableList(funcBuilders);
        listeners = Collections.unmodifiableList(listeners);
        metaAccessor.touch(configuration.getDatasourceBeanId(), metaOperations ->
                loadDesignDocuments(configuration.getModule(), configuration, metaOperations)
        );
        for (Map.Entry<String, ModuleConfiguration> entry : configuration.getOtherModules().entrySet()) {
            ModuleConfiguration config = entry.getValue();
            String moduleName = config.getModule();
            metaAccessor.touch(config.getDatasourceBeanId(), metaOperations ->
                    loadDesignDocuments(moduleName, config, metaOperations)
            );
        }
    }

    protected void loadDesignDocuments(String moduleName, ModuleConfiguration config, MetaOperations metaOpt)
            throws Exception {
        String defaultDatabase = config.getDatabase();
        if (StringUtils.isBlank(defaultDatabase)) {
            defaultDatabase = metaOpt.currentDatabase();
            if (StringUtils.isBlank(defaultDatabase)) {
                throw new InitializationException(String.format(
                        "The module '%s' does not specify a default database", moduleName));
            }
            config.setDatabase(defaultDatabase);
        }
        String defaultDs = defaultDatabase;
        String designDir = config.getDesignDir();
        Path designPath = GenToolKit.resolveDesignPath(designDir);
        designPath.toFile().mkdirs();
        GenToolKit.walkFileTree(designPath, (path, attrs) -> {
            File xmlFile = path.toFile();
            String fileName = xmlFile.getName();
            if (!fileName.endsWith(".xml")) {
                return FileVisitResult.CONTINUE;
            }
            Document designDoc;
            try {
                designDoc = GenToolKit.readXml(xmlFile);
            } catch (Exception e) {
                throw new InitializationException(String.format("Failed to read xml: %s", xmlFile.getAbsolutePath()), e);
            }
            Element meta = designDoc.getRootElement();
            String database = meta.attributeValue("database");
            if (StringUtils.isBlank(database)) {
                database = defaultDs;
            }
            metaOpt.checkDatabase(database);
            String tableName = meta.attributeValue("table");
            Assert.hasText(tableName, String.format("Attribute 'table' must be set, xml: %s",
                    xmlFile.getAbsolutePath()
            ));
            Table table = metaOpt.table(database, tableName);
            Assert.notNull(table, String.format("Table '%s' not found, xml: %s",
                    tableName, xmlFile.getAbsolutePath()
            ));
            if (designXmlPool.computeIfAbsent(moduleName, k -> new ConcurrentHashMap<>())
                    .putIfAbsent((database + "@" + tableName).toLowerCase(), new DesignXml(xmlFile, designDoc)) != null) {
                throw new IOException(String.format("Duplicate mapper design for table '%s'", tableName));
            }
            return FileVisitResult.CONTINUE;
        });
    }

    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        try {
            init();
        } catch (Exception e) {
            GenToolKit.handleException(log, "Init failed", e);
        }
    }
}
